SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
6.4 KB · 128 lines typescript
Raw Blame History
1// Résumés intelligents : générés depuis les sources du cours (RAG), avec citations validées.2import { NextResponse } from "next/server";3import { z } from "zod";4import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts";5import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";6import { all, run } from "@/lib/db/index.ts";7import { normalizeCourse } from "@/lib/learning/helpers.ts";8import { getPrompt } from "@/lib/prompts.ts";9import { completeChat } from "@/lib/openrouter/client.ts";10import { resolvePreset, getModel, estimateCost } from "@/lib/openrouter/registry.ts";11import { buildContext, type RetrievedChunk } from "@/lib/rag/search.ts";12import { resolveCitations } from "@/lib/rag/citations.ts";13import { checkBudget, logActivity, logUsage } from "@/lib/usage.ts";1415export async function GET(_req: Request, ctx: { params: Promise<{ course: string }> }) {16  try {17    const user = await requireUser();18    const course = normalizeCourse((await ctx.params).course);19    requireEnrollment(user.id, course);20    const summaries = all(21      `SELECT id, scope, ref, title, content, citations, created_by, created_at FROM summaries22       WHERE course_code = ? AND (owner_user_id IS NULL OR owner_user_id = ?) ORDER BY id DESC LIMIT 100`,23      course, user.id24    );25    return NextResponse.json({ summaries });26  } catch (e) {27    return apiError(e);28  }29}3031const schema = z.object({32  scope: z.enum(["week", "concept", "exam-prep"]),33  week: z.number().int().min(1).max(14).optional(),34  conceptSlug: z.string().max(120).optional(),35  style: z.enum(["ultra-court", "detaille", "avec-formules"]).default("detaille"),36});3738export async function POST(req: Request, ctx: { params: Promise<{ course: string }> }) {39  try {40    await assertSameOrigin();41    const user = await requireUser();42    const course = normalizeCourse((await ctx.params).course);43    requireEnrollment(user.id, course);44    const body = await parseBody(req, schema);45    const budget = checkBudget(user.id);46    if (!budget.ok) return NextResponse.json({ error: budget.reason }, { status: 429 });4748    // Sélection directe des fragments (pas de recherche : on veut la couverture du scope).49    let chunks: RetrievedChunk[] = [];50    let title = "";51    let ref = "";52    if (body.scope === "week") {53      if (!body.week) return NextResponse.json({ error: "Semaine requise." }, { status: 400 });54      chunks = all<RetrievedChunk>(55        `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label,56                c.section_title, c.title, c.content, c.box_types, c.week,57                d.title as doc_title, d.path as doc_path, d.filename, 1.0 as score58         FROM chunks c JOIN documents d ON d.id = c.document_id59         WHERE c.course_code = ? AND c.space = ? AND c.week = ? AND c.ref_type = 'slide'60         ORDER BY c.ref_number LIMIT 90`,61        course, `official-${course.toLowerCase()}`, body.week62      );63      title = `Résumé — Séance ${body.week}`;64      ref = `semaine-${body.week}`;65    } else if (body.scope === "concept") {66      if (!body.conceptSlug) return NextResponse.json({ error: "Concept requis." }, { status: 400 });67      const { hybridSearch } = await import("@/lib/rag/search.ts");68      const concept = all<{ name: string; description: string }>(69        "SELECT name, description FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug70      )[0];71      if (!concept) return NextResponse.json({ error: "Concept inconnu." }, { status: 404 });72      chunks = await hybridSearch({ query: concept.name + ". " + concept.description, spaces: [`official-${course.toLowerCase()}`], k: 12 });73      title = `Résumé — ${concept.name}`;74      ref = body.conceptSlug;75    } else {76      chunks = all<RetrievedChunk>(77        `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label,78                c.section_title, c.title, c.content, c.box_types, c.week,79                d.title as doc_title, d.path as doc_path, d.filename, 1.0 as score80         FROM chunks c JOIN documents d ON d.id = c.document_id81         WHERE c.course_code = ? AND c.space = ? AND c.box_types != '' AND c.ref_type = 'slide'82         ORDER BY c.week, c.ref_number LIMIT 80`,83        course, `official-${course.toLowerCase()}`84      );85      title = "Résumé de préparation à l'examen";86      ref = "exam-prep";87    }88    if (!chunks.length) return NextResponse.json({ error: "Aucun contenu trouvé pour ce choix." }, { status: 404 });8990    const context = buildContext(chunks, 26_000);91    const styleInstr = {92      "ultra-court": "Résumé ULTRA-COURT : une page maximum, uniquement l'essentiel en listes serrées.",93      detaille: "Résumé DÉTAILLÉ : structuré par sous-thèmes, avec exemples brefs et erreurs fréquentes.",94      "avec-formules": "Résumé AXÉ FORMULES : chaque formule en LaTeX avec ses variables définies, ses conditions d'application et un mini-exemple chiffré.",95    }[body.style];96    const model = await resolvePreset("recommande");97    const t0 = Date.now();98    const { text, promptTokens, completionTokens } = await completeChat({99      model,100      messages: [101        {102          role: "system",103          content: [getPrompt("base-system"), course === "IMM1003" ? getPrompt("course-imm1003") : getPrompt("course-imm1033"), getPrompt("rag-grounding"), getPrompt("citation-policy")].join("\n\n---\n\n"),104        },105        {106          role: "user",107          content: `Produis « ${title} » pour le cours ${course}. ${styleInstr}\nTermine par une section « Questions d'auto-vérification » (3 questions sans réponse).\n\nMatériel du cours :\n${context.text}`,108        },109      ],110      maxTokens: 4000,111      temperature: 0.3,112    });113    const mi = await getModel(model);114    logUsage({ userId: user.id, model, kind: "summary", tokensIn: promptTokens, tokensOut: completionTokens, cost: mi ? estimateCost(mi, promptTokens, completionTokens) : 0, latencyMs: Date.now() - t0 });115116    const { cleaned, citations } = resolveCitations(text, context);117    const r = run(118      `INSERT INTO summaries (course_code, scope, ref, title, content, citations, owner_user_id, created_by)119       VALUES (?, ?, ?, ?, ?, ?, ?, 'ai')`,120      course, body.scope, ref, title, cleaned, JSON.stringify(citations), user.id121    );122    logActivity(user.id, "summary", course, 120);123    return NextResponse.json({ ok: true, id: Number(r.lastInsertRowid), title, content: cleaned, citations });124  } catch (e) {125    return apiError(e);126  }127}128